3.3 Go语言的垃圾回收机制

在前面两节我们都提到了垃圾回收GC,栈内存会由系统自动处理,但是堆内存是需要由GC处理的。

GC在程序运行后启动回收监测,对不需要的内存进行回收处理,这是Go语言的一大特性,同时GC的性能也会直接影响到程序的性能。

本节我们将针对垃圾回收算法、回收机制、优化等方面进行讲解。

本节代码存放目录为 lesson9

什么是垃圾回收?

垃圾回收(Garbage Collection,简称GC)是自动管理内存的一种机制,目的是回收不再使用的内存,以避免内存泄漏。

Go语言中,GC主要用于管理堆内存,堆内存主要用于动态分配的数据,比如通过newmake等操作分配的内存。

C/C++等语言中,内存的分配和释放是由开发者手动管理的。

而在Go语言中,GC机制会自动回收不再使用的内存,从而减少了手动管理的复杂性和错误的可能性。

但由于这一特性,GC的性能也会直接影响到程序的整体性能。

GO语言中的GC机制

Go语言的垃圾回收器自最初的实现以来,经历了多次改进和优化。

当前的GC机制采用了标记-清除算法,并在此基础上引入了三色标记法来提高效率和减少对程序运行的影响。


标记-清除算法

标记-清除算法是最基本的垃圾回收算法,它的工作流程可以分为两个阶段:

  • 标记阶段:遍历所有的活动对象并将其标记为已使用。这些对象通常通过程序中的根对象(如全局变量、栈上的局部变量)进行递归查找。

  • 清除阶段:遍历堆内存,将所有未标记的对象(即垃圾对象)回收。

总的来说就是检查所有的对象,将正在使用的进行标记,将未标记的回收。

这种算法虽然简单,但是也有一些缺点。在执行标记清除的时候,为了避免出现冲突,程序会暂停执行。

这对于程序来说肯定是会有影响的,比如堆内存很大时,需要执行的时间变长,也就意味着主程序暂停的时间变长。


三色标记法

为了优化标记-清除算法,Go语言引入了三色标记法。三色标记法的核心思想是通过颜色将对象做出不同标记,更细粒度的控制和减少垃圾回收对程序的影响。

简单来说,就是GC会将不同的对象标记为不同的颜色,之后将固定颜色的对象清除,这样的操作就进一步细化了操作,不再是简单的单一标记清除。

  • 白色:初始状态,表示未标记的对象。GC认为所有白色对象都是垃圾,最终会被回收。

  • 灰色:标记过程中正在被处理的对象。GC正在扫描这些对象的引用,以确定哪些对象还在使用。

  • 黑色:已经处理完成并确认是活动对象的内存块。黑色对象和它们引用的对象将被保留,不会被回收。

三色标记法通过将垃圾回收过程分成多个小步骤,允许在标记阶段与程序的正常执行交替进行,从而减少暂停的时间。

这种方式称为增量GC,它能在回收过程中减少程序的停顿时间。

GC性能优化

减少内存分配

在之前的章节中我们已经讲解过内存分配的内容,栈上的内存分配速度非常快,且由系统自动管理,函数返回时自动释放内存,不会触发GC。因此,尽可能使用栈分配而不是堆分配可以减轻GC的负担。

Go编译器会自动进行逃逸分析,判断变量是应该分配在栈上还是堆上。为了减少逃逸到堆上的变量数量,可以尽量避免将局部变量的指针返回,或者尽量减少闭包中捕获的变量。

避免频繁创建短生命周期的对象,尤其是在循环或高频调用中,因为这些对象往往会逃逸到堆上,增加GC的负担。这种情况下可以通过重用对象(例如使用缓冲池或对象池)来减少新对象的分配。


使用对象池

对象池是一种将对象重用的机制,可以避免频繁的分配和释放操作。sync.PoolGo语言标准库中提供的对象池工具。

适合用于那些频繁创建和销毁的对象,尤其是长生命周期的对象。例如,处理网络连接、数据库连接、缓冲区等。

为方便理解,我们可以通过下面的代码查看:

type MyStruct struct {
    Field1 int
    Field2 string
}

// 创建一个对象池
var structPool = sync.Pool{
    New: func() interface{} {
        return &MyStruct{} // 当对象池中没有可用对象时,创建一个新的结构体
    },
}

// 一个示例函数,返回一个结构体
func getStruct() *MyStruct {
    // 从对象池中获取一个结构体
    s := structPool.Get().(*MyStruct)

    // 重置结构体的字段(可选)
    s.Field1 = 0
    s.Field2 = ""

    return s
}

// 使用完结构体后,将其放回对象池
func releaseStruct(s *MyStruct) {
    structPool.Put(s)
}

func main() {
    // 获取结构体对象
    s1 := getStruct()
    s1.Field1 = 10
    s1.Field2 = "Hello"
    fmt.Printf("s1: %+v\n", s1)

    // 使用完后,将结构体放回对象池
    releaseStruct(s1)

    // 再次获取结构体对象
    s2 := getStruct()
    fmt.Printf("s2: %+v\n", s2)
}

在上面的代码中,我们使用了一个structPool用于进行对象池逻辑,通过getStruct获取到实际的对象,通过releaseStruct放回对象。

对象池的概念其实简单来说就是:如果一个对象在短时间内需要重复创建,那么就可以使用对象池,这样就不会一直重复创建。

比如说上面的例子中,我们在最后增加一些代码:

for i := 0; i < 10; i++ {
    s := getStruct()          // 从对象池获取一个结构体对象
    s.Field1 = i              // 使用结构体对象
    fmt.Printf("s: %+v\n", s) // 打印结构体对象
    releaseStruct(s)          // 使用完毕后将对象放回对象池
}

在上面的代码中,我们在循环内通过对象池一直使用的都是同一个对象,也就是说一直都没有创建新的对象,没有新的内存分配。

那么如果我们没有使用对象的话,上面的代码最终就会创建10次对象,GC就需要回收10倍的数据。

但是需要注意的是:对象池并不是一直不回收的,GC在运行时同样会进行回收,所以对象池用于短期频繁创建的对象。


合理设置GOGC

GOGC是一个控制GC运行频率的环境变量,它的值是一个百分比,表示每次GC可用堆内存的增长比例。

例如,GOGC=100表示允许堆内存增长100%后再触发下一次GC

我们可以通过设置更高的GOGC值来减少GC的频率,从而提高程序的性能,尤其是在内存充裕的情况下。

export GOGC=200  # 设置GOGC值为200%

需要注意的是,如果设置的值太高,很久才GC一次,那么可能导致程序占用过多的内存。

除了直接设置环境变量以外,我们还可以在代码中直接设置。如下代码所示:

// 设置新的 GOGC 值为 200
defaultGc := debug.SetGCPercent(200)
fmt.Printf("Updated GOGC to 200, old default-> %d\n", defaultGc)

调用debug.SetGCPercent可以设置频率,同时会返回上一次的设置。

处理上面的方法以外,我们还可以进行手动GC,也就是说直接调用代码进行GC。如下代码所示:

// 禁用自动GC
debug.SetGCPercent(-1)

// 分配一些内存
data := make([]byte, 30*1024*1024) // 分配10 MiB的内存
fmt.Println("手动触发GC前的内存状态:")
printMemStats()

// 手动触发GC
runtime.GC()

fmt.Println("手动触发GC后的内存状态:")
printMemStats()

// 防止内存被优化掉
_ = data

func printMemStats() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
    fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
    fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
    fmt.Printf("\tNumGC = %v\n", m.NumGC)
}

func bToMb(b uint64) uint64 {
    return b / 1024 / 1024
}

结果输出如下所示:

手动触发GC前的内存状态:
Alloc = 30 MiB  TotalAlloc = 30 MiB     Sys = 40 MiB    NumGC = 0
手动触发GC后的内存状态:
Alloc = 0 MiB   TotalAlloc = 30 MiB     Sys = 40 MiB    NumGC = 1

在上面的代码中,我们直接调用runtime.GC()手动触发了GC,通过输出结果也可以看出,调用后Alloc的内存占用变为了0 MB,也就是说内存此时已经被回收掉了。


pprof分析

Go语言为我们提供了在线分析工具pprof,通过该工具我们可以查看内存占用、协程使用情况。

代码如下所示:

import _ "net/http/pprof"

if err := http.ListenAndServe(":8080", nil); err != nil {
    fmt.Printf("ListenAndServe err: %v", err)
}

之后我们在浏览器访问:http://127.0.0.1:8080/debug/pprof/即可看到分析信息。其中包括当前分配内存、协程等信息,可以通过说明逐一查看。如下图所示:

小结

本节我们讲解了Go语言的垃圾回收机制以及优化内容,这在实际的开发中对于性能优化还是很有用的。

关于本节总结如下:

  • GC回收堆上的内存

  • 使用标记-清除三色标记法实现内存释放

  • 减少堆内存分配、使用对象池、设置GOGC环境变量可以提高GC效率

  • 使用pprof可以对GC及程序运行效率进行分析

results matching ""

    No results matching ""